home *** CD-ROM | disk | FTP | other *** search
/ Clickx 115 / Clickx 115.iso / software / tools / windows / tails-i386-0.16.iso / live / filesystem.squashfs / usr / share / pyshared / gdata / client.py < prev    next >
Encoding:
Python Source  |  2010-02-11  |  46.3 KB  |  1,127 lines

  1. #!/usr/bin/env python
  2. #
  3. # Copyright (C) 2008, 2009 Google Inc.
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. #      http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. # See the License for the specific language governing permissions and
  15. # limitations under the License.
  16.  
  17.  
  18. # This module is used for version 2 of the Google Data APIs.
  19.  
  20.  
  21. """Provides a client to interact with Google Data API servers.
  22.  
  23. This module is used for version 2 of the Google Data APIs. The primary class
  24. in this module is GDClient.
  25.  
  26.   GDClient: handles auth and CRUD operations when communicating with servers.
  27.   GDataClient: deprecated client for version one services. Will be removed.
  28. """
  29.  
  30.  
  31. __author__ = 'j.s@google.com (Jeff Scudder)'
  32.  
  33.  
  34. import re
  35. import atom.client
  36. import atom.core
  37. import atom.http_core
  38. import gdata.gauth
  39. import gdata.data
  40.  
  41.  
  42. class Error(Exception):
  43.   pass
  44.  
  45.  
  46. class RequestError(Error):
  47.   status = None
  48.   reason = None
  49.   body = None
  50.   headers = None
  51.  
  52.  
  53. class RedirectError(RequestError):
  54.   pass
  55.  
  56.  
  57. class CaptchaChallenge(RequestError):
  58.   captcha_url = None
  59.   captcha_token = None
  60.  
  61.  
  62. class ClientLoginTokenMissing(Error):
  63.   pass
  64.  
  65.  
  66. class MissingOAuthParameters(Error):
  67.   pass
  68.  
  69.  
  70. class ClientLoginFailed(RequestError):
  71.   pass
  72.  
  73.  
  74. class UnableToUpgradeToken(RequestError):
  75.   pass
  76.  
  77.  
  78. class Unauthorized(Error):
  79.   pass
  80.  
  81.  
  82. class BadAuthenticationServiceURL(RedirectError):
  83.   pass
  84.  
  85.  
  86. class BadAuthentication(RequestError):
  87.   pass
  88.  
  89.  
  90. class NotModified(RequestError):
  91.   pass
  92.  
  93. class NotImplemented(RequestError):
  94.   pass
  95.  
  96.  
  97. def error_from_response(message, http_response, error_class,
  98.                         response_body=None):
  99.  
  100.   """Creates a new exception and sets the HTTP information in the error.
  101.  
  102.   Args:
  103.    message: str human readable message to be displayed if the exception is
  104.             not caught.
  105.    http_response: The response from the server, contains error information.
  106.    error_class: The exception to be instantiated and populated with
  107.                 information from the http_response
  108.    response_body: str (optional) specify if the response has already been read
  109.                   from the http_response object.
  110.   """
  111.   if response_body is None:
  112.     body = http_response.read()
  113.   else:
  114.     body = response_body
  115.   error = error_class('%s: %i, %s' % (message, http_response.status, body))
  116.   error.status = http_response.status
  117.   error.reason = http_response.reason
  118.   error.body = body
  119.   error.headers = atom.http_core.get_headers(http_response)
  120.   return error
  121.  
  122.  
  123. def get_xml_version(version):
  124.   """Determines which XML schema to use based on the client API version.
  125.  
  126.   Args:
  127.     version: string which is converted to an int. The version string is in
  128.              the form 'Major.Minor.x.y.z' and only the major version number
  129.              is considered. If None is provided assume version 1.
  130.   """
  131.   if version is None:
  132.     return 1
  133.   return int(version.split('.')[0])
  134.  
  135.  
  136. class GDClient(atom.client.AtomPubClient):
  137.   """Communicates with Google Data servers to perform CRUD operations.
  138.  
  139.   This class is currently experimental and may change in backwards
  140.   incompatible ways.
  141.  
  142.   This class exists to simplify the following three areas involved in using
  143.   the Google Data APIs.
  144.  
  145.   CRUD Operations:
  146.  
  147.   The client provides a generic 'request' method for making HTTP requests.
  148.   There are a number of convenience methods which are built on top of
  149.   request, which include get_feed, get_entry, get_next, post, update, and
  150.   delete. These methods contact the Google Data servers.
  151.  
  152.   Auth:
  153.  
  154.   Reading user-specific private data requires authorization from the user as
  155.   do any changes to user data. An auth_token object can be passed into any
  156.   of the HTTP requests to set the Authorization header in the request.
  157.  
  158.   You may also want to set the auth_token member to a an object which can
  159.   use modify_request to set the Authorization header in the HTTP request.
  160.  
  161.   If you are authenticating using the email address and password, you can
  162.   use the client_login method to obtain an auth token and set the
  163.   auth_token member.
  164.  
  165.   If you are using browser redirects, specifically AuthSub, you will want
  166.   to use gdata.gauth.AuthSubToken.from_url to obtain the token after the
  167.   redirect, and you will probably want to updgrade this since use token
  168.   to a multiple use (session) token using the upgrade_token method.
  169.  
  170.   API Versions:
  171.  
  172.   This client is multi-version capable and can be used with Google Data API
  173.   version 1 and version 2. The version should be specified by setting the
  174.   api_version member to a string, either '1' or '2'.
  175.   """
  176.  
  177.   # The gsessionid is used by Google Calendar to prevent redirects.
  178.   __gsessionid = None
  179.   api_version = None
  180.   # Name of the Google Data service when making a ClientLogin request.
  181.   auth_service = None
  182.   # URL prefixes which should be requested for AuthSub and OAuth.
  183.   auth_scopes = None
  184.  
  185.   def request(self, method=None, uri=None, auth_token=None,
  186.               http_request=None, converter=None, desired_class=None,
  187.               redirects_remaining=4, **kwargs):
  188.     """Make an HTTP request to the server.
  189.  
  190.     See also documentation for atom.client.AtomPubClient.request.
  191.  
  192.     If a 302 redirect is sent from the server to the client, this client
  193.     assumes that the redirect is in the form used by the Google Calendar API.
  194.     The same request URI and method will be used as in the original request,
  195.     but a gsessionid URL parameter will be added to the request URI with
  196.     the value provided in the server's 302 redirect response. If the 302
  197.     redirect is not in the format specified by the Google Calendar API, a
  198.     RedirectError will be raised containing the body of the server's
  199.     response.
  200.  
  201.     The method calls the client's modify_request method to make any changes
  202.     required by the client before the request is made. For example, a
  203.     version 2 client could add a GData-Version: 2 header to the request in
  204.     its modify_request method.
  205.  
  206.     Args:
  207.       method: str The HTTP verb for this request, usually 'GET', 'POST',
  208.               'PUT', or 'DELETE'
  209.       uri: atom.http_core.Uri, str, or unicode The URL being requested.
  210.       auth_token: An object which sets the Authorization HTTP header in its
  211.                   modify_request method. Recommended classes include
  212.                   gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken
  213.                   among others.
  214.       http_request: (optional) atom.http_core.HttpRequest
  215.       converter: function which takes the body of the response as it's only
  216.                  argument and returns the desired object.
  217.       desired_class: class descended from atom.core.XmlElement to which a
  218.                      successful response should be converted. If there is no
  219.                      converter function specified (converter=None) then the
  220.                      desired_class will be used in calling the
  221.                      atom.core.parse function. If neither
  222.                      the desired_class nor the converter is specified, an
  223.                      HTTP reponse object will be returned.
  224.       redirects_remaining: (optional) int, if this number is 0 and the
  225.                            server sends a 302 redirect, the request method
  226.                            will raise an exception. This parameter is used in
  227.                            recursive request calls to avoid an infinite loop.
  228.  
  229.     Any additional arguments are passed through to
  230.     atom.client.AtomPubClient.request.
  231.  
  232.     Returns:
  233.       An HTTP response object (see atom.http_core.HttpResponse for a
  234.       description of the object's interface) if no converter was
  235.       specified and no desired_class was specified. If a converter function
  236.       was provided, the results of calling the converter are returned. If no
  237.       converter was specified but a desired_class was provided, the response
  238.       body will be converted to the class using
  239.       atom.core.parse.
  240.     """
  241.     if isinstance(uri, (str, unicode)):
  242.       uri = atom.http_core.Uri.parse_uri(uri)
  243.  
  244.     # Add the gsession ID to the URL to prevent further redirects.
  245.     # TODO: If different sessions are using the same client, there will be a
  246.     # multitude of redirects and session ID shuffling.
  247.     # If the gsession ID is in the URL, adopt it as the standard location.
  248.     if uri is not None and uri.query is not None and 'gsessionid' in uri.query:
  249.       self.__gsessionid = uri.query['gsessionid']
  250.     # The gsession ID could also be in the HTTP request.
  251.     elif (http_request is not None and http_request.uri is not None
  252.           and http_request.uri.query is not None
  253.           and 'gsessionid' in http_request.uri.query):
  254.       self.__gsessionid = http_request.uri.query['gsessionid']
  255.     # If the gsession ID is stored in the client, and was not present in the
  256.     # URI then add it to the URI.
  257.     elif self.__gsessionid is not None:
  258.       uri.query['gsessionid'] = self.__gsessionid
  259.  
  260.     # The AtomPubClient should call this class' modify_request before
  261.     # performing the HTTP request.
  262.     #http_request = self.modify_request(http_request)
  263.  
  264.     response = atom.client.AtomPubClient.request(self, method=method,
  265.         uri=uri, auth_token=auth_token, http_request=http_request, **kwargs)
  266.     # On success, convert the response body using the desired converter
  267.     # function if present.
  268.     if response is None:
  269.       return None
  270.     if response.status == 200 or response.status == 201:
  271.       if converter is not None:
  272.         return converter(response)
  273.       elif desired_class is not None:
  274.         if self.api_version is not None:
  275.           return atom.core.parse(response.read(), desired_class,
  276.                                  version=get_xml_version(self.api_version))
  277.         else:
  278.           # No API version was specified, so allow parse to
  279.           # use the default version.
  280.           return atom.core.parse(response.read(), desired_class)
  281.       else:
  282.         return response
  283.     # TODO: move the redirect logic into the Google Calendar client once it
  284.     # exists since the redirects are only used in the calendar API.
  285.     elif response.status == 302:
  286.       if redirects_remaining > 0:
  287.         location = (response.getheader('Location')
  288.                     or response.getheader('location'))
  289.         if location is not None:
  290.           m = re.compile('[\?\&]gsessionid=(\w*)').search(location)
  291.           if m is not None:
  292.             self.__gsessionid = m.group(1)
  293.           # Make a recursive call with the gsession ID in the URI to follow
  294.           # the redirect.
  295.           return self.request(method=method, uri=uri, auth_token=auth_token,
  296.                               http_request=http_request, converter=converter,
  297.                               desired_class=desired_class,
  298.                               redirects_remaining=redirects_remaining-1,
  299.                               **kwargs)
  300.         else:
  301.           raise error_from_response('302 received without Location header',
  302.                                     response, RedirectError)
  303.       else:
  304.         raise error_from_response('Too many redirects from server',
  305.                                   response, RedirectError)
  306.     elif response.status == 401:
  307.       raise error_from_response('Unauthorized - Server responded with',
  308.                                 response, Unauthorized)
  309.     elif response.status == 304:
  310.       raise error_from_response('Entry Not Modified - Server responded with',
  311.                                 response, NotModified)
  312.     elif response.status == 501:
  313.       raise error_from_response(
  314.           'This API operation is not implemented. - Server responded with',
  315.           response, NotImplemented)
  316.     # If the server's response was not a 200, 201, 302, 304, 401, or 501, raise
  317.     # an exception.
  318.     else:
  319.       raise error_from_response('Server responded with', response,
  320.                                 RequestError)
  321.  
  322.   Request = request
  323.  
  324.   def request_client_login_token(
  325.       self, email, password, source, service=None,
  326.       account_type='HOSTED_OR_GOOGLE',
  327.       auth_url=atom.http_core.Uri.parse_uri(
  328.           'https://www.google.com/accounts/ClientLogin'),
  329.       captcha_token=None, captcha_response=None):
  330.     service = service or self.auth_service
  331.     # Set the target URL.
  332.     http_request = atom.http_core.HttpRequest(uri=auth_url, method='POST')
  333.     http_request.add_body_part(
  334.         gdata.gauth.generate_client_login_request_body(email=email,
  335.             password=password, service=service, source=source,
  336.             account_type=account_type, captcha_token=captcha_token,
  337.             captcha_response=captcha_response),
  338.         'application/x-www-form-urlencoded')
  339.  
  340.     # Use the underlying http_client to make the request.
  341.     response = self.http_client.request(http_request)
  342.  
  343.     response_body = response.read()
  344.     if response.status == 200:
  345.       token_string = gdata.gauth.get_client_login_token_string(response_body)
  346.       if token_string is not None:
  347.         return gdata.gauth.ClientLoginToken(token_string)
  348.       else:
  349.         raise ClientLoginTokenMissing(
  350.             'Recieved a 200 response to client login request,'
  351.             ' but no token was present. %s' % (response_body,))
  352.     elif response.status == 403:
  353.       captcha_challenge = gdata.gauth.get_captcha_challenge(response_body)
  354.       if captcha_challenge:
  355.         challenge = CaptchaChallenge('CAPTCHA required')
  356.         challenge.captcha_url = captcha_challenge['url']
  357.         challenge.captcha_token = captcha_challenge['token']
  358.         raise challenge
  359.       elif response_body.splitlines()[0] == 'Error=BadAuthentication':
  360.         raise BadAuthentication('Incorrect username or password')
  361.       else:
  362.         raise error_from_response('Server responded with a 403 code',
  363.                                   response, RequestError, response_body)
  364.     elif response.status == 302:
  365.       # Google tries to redirect all bad URLs back to
  366.       # http://www.google.<locale>. If a redirect
  367.       # attempt is made, assume the user has supplied an incorrect
  368.       # authentication URL
  369.       raise error_from_response('Server responded with a redirect',
  370.                                 response, BadAuthenticationServiceURL,
  371.                                 response_body)
  372.     else:
  373.       raise error_from_response('Server responded to ClientLogin request',
  374.                                 response, ClientLoginFailed, response_body)
  375.  
  376.   RequestClientLoginToken = request_client_login_token
  377.  
  378.   def client_login(self, email, password, source, service=None,
  379.                    account_type='HOSTED_OR_GOOGLE',
  380.                    auth_url=atom.http_core.Uri.parse_uri(
  381.                        'https://www.google.com/accounts/ClientLogin'),
  382.                    captcha_token=None, captcha_response=None):
  383.     """Performs an auth request using the user's email address and password.
  384.     
  385.     In order to modify user specific data and read user private data, your
  386.     application must be authorized by the user. One way to demonstrage
  387.     authorization is by including a Client Login token in the Authorization
  388.     HTTP header of all requests. This method requests the Client Login token
  389.     by sending the user's email address, password, the name of the
  390.     application, and the service code for the service which will be accessed
  391.     by the application. If the username and password are correct, the server
  392.     will respond with the client login code and a new ClientLoginToken
  393.     object will be set in the client's auth_token member. With the auth_token
  394.     set, future requests from this client will include the Client Login
  395.     token.
  396.     
  397.     For a list of service names, see 
  398.     http://code.google.com/apis/gdata/faq.html#clientlogin
  399.     For more information on Client Login, see:
  400.     http://code.google.com/apis/accounts/docs/AuthForInstalledApps.html
  401.  
  402.     Args:
  403.       email: str The user's email address or username.
  404.       password: str The password for the user's account.
  405.       source: str The name of your application. This can be anything you
  406.               like but should should give some indication of which app is
  407.               making the request.
  408.       service: str The service code for the service you would like to access.
  409.                For example, 'cp' for contacts, 'cl' for calendar. For a full
  410.                list see
  411.                http://code.google.com/apis/gdata/faq.html#clientlogin
  412.                If you are using a subclass of the gdata.client.GDClient, the
  413.                service will usually be filled in for you so you do not need
  414.                to specify it. For example see BloggerClient,
  415.                SpreadsheetsClient, etc.
  416.       account_type: str (optional) The type of account which is being
  417.                     authenticated. This can be either 'GOOGLE' for a Google
  418.                     Account, 'HOSTED' for a Google Apps Account, or the
  419.                     default 'HOSTED_OR_GOOGLE' which will select the Google
  420.                     Apps Account if the same email address is used for both
  421.                     a Google Account and a Google Apps Account.
  422.       auth_url: str (optional) The URL to which the login request should be
  423.                 sent.
  424.       captcha_token: str (optional) If a previous login attempt was reponded
  425.                      to with a CAPTCHA challenge, this is the token which
  426.                      identifies the challenge (from the CAPTCHA's URL).
  427.       captcha_response: str (optional) If a previous login attempt was
  428.                         reponded to with a CAPTCHA challenge, this is the
  429.                         response text which was contained in the challenge.
  430.  
  431.       Returns:
  432.         None
  433.  
  434.       Raises:
  435.         A RequestError or one of its suclasses: BadAuthentication,
  436.         BadAuthenticationServiceURL, ClientLoginFailed,
  437.         ClientLoginTokenMissing, or CaptchaChallenge
  438.     """
  439.     service = service or self.auth_service
  440.     self.auth_token = self.request_client_login_token(email, password,
  441.         source, service=service, account_type=account_type, auth_url=auth_url,
  442.         captcha_token=captcha_token, captcha_response=captcha_response)
  443.  
  444.   ClientLogin = client_login
  445.  
  446.   def upgrade_token(self, token=None, url=atom.http_core.Uri.parse_uri(
  447.       'https://www.google.com/accounts/AuthSubSessionToken')):
  448.     """Asks the Google auth server for a multi-use AuthSub token.
  449.  
  450.     For details on AuthSub, see:
  451.     http://code.google.com/apis/accounts/docs/AuthSub.html
  452.  
  453.     Args:
  454.       token: gdata.gauth.AuthSubToken or gdata.gauth.SecureAuthSubToken
  455.           (optional) If no token is passed in, the client's auth_token member
  456.           is used to request the new token. The token object will be modified
  457.           to contain the new session token string.
  458.       url: str or atom.http_core.Uri (optional) The URL to which the token
  459.           upgrade request should be sent. Defaults to:
  460.           https://www.google.com/accounts/AuthSubSessionToken
  461.  
  462.     Returns:
  463.       The upgraded gdata.gauth.AuthSubToken object.
  464.     """
  465.     # Default to using the auth_token member if no token is provided.
  466.     if token is None:
  467.       token = self.auth_token
  468.     # We cannot upgrade a None token.
  469.     if token is None:
  470.       raise UnableToUpgradeToken('No token was provided.')
  471.     if not isinstance(token, gdata.gauth.AuthSubToken):
  472.       raise UnableToUpgradeToken(
  473.           'Cannot upgrade the token because it is not an AuthSubToken object.')
  474.     http_request = atom.http_core.HttpRequest(uri=url, method='GET')
  475.     token.modify_request(http_request)
  476.     # Use the lower level HttpClient to make the request.
  477.     response = self.http_client.request(http_request)
  478.     if response.status == 200:
  479.       token._upgrade_token(response.read())
  480.       return token
  481.     else:
  482.       raise UnableToUpgradeToken(
  483.           'Server responded to token upgrade request with %s: %s' % (
  484.               response.status, response.read()))
  485.  
  486.   UpgradeToken = upgrade_token
  487.  
  488.   def revoke_token(self, token=None, url=atom.http_core.Uri.parse_uri(
  489.       'https://www.google.com/accounts/AuthSubRevokeToken')):
  490.     """Requests that the token be invalidated.
  491.     
  492.     This method can be used for both AuthSub and OAuth tokens (to invalidate
  493.     a ClientLogin token, the user must change their password).
  494.  
  495.     Returns:
  496.       True if the server responded with a 200.
  497.  
  498.     Raises:
  499.       A RequestError if the server responds with a non-200 status.
  500.     """
  501.     # Default to using the auth_token member if no token is provided.
  502.     if token is None:
  503.       token = self.auth_token
  504.  
  505.     http_request = atom.http_core.HttpRequest(uri=url, method='GET')
  506.     token.modify_request(http_request)
  507.     response = self.http_client.request(http_request)
  508.     if response.status != 200:
  509.       raise error_from_response('Server sent non-200 to revoke token',
  510.                                 response, RequestError, response_body)
  511.  
  512.     return True
  513.  
  514.   RevokeToken = revoke_token
  515.  
  516.   def get_oauth_token(self, scopes, next, consumer_key, consumer_secret=None,
  517.                       rsa_private_key=None,
  518.                       url=gdata.gauth.REQUEST_TOKEN_URL):
  519.     """Obtains an OAuth request token to allow the user to authorize this app.
  520.  
  521.     Once this client has a request token, the user can authorize the request
  522.     token by visiting the authorization URL in their browser. After being
  523.     redirected back to this app at the 'next' URL, this app can then exchange
  524.     the authorized request token for an access token.
  525.  
  526.     For more information see the documentation on Google Accounts with OAuth:
  527.     http://code.google.com/apis/accounts/docs/OAuth.html#AuthProcess
  528.  
  529.     Args:
  530.       scopes: list of strings or atom.http_core.Uri objects which specify the
  531.           URL prefixes which this app will be accessing. For example, to access
  532.           the Google Calendar API, you would want to use scopes:
  533.           ['https://www.google.com/calendar/feeds/',
  534.            'http://www.google.com/calendar/feeds/']
  535.       next: str or atom.http_core.Uri object, The URL which the user's browser
  536.           should be sent to after they authorize access to their data. This
  537.           should be a URL in your application which will read the token
  538.           information from the URL and upgrade the request token to an access
  539.           token.
  540.       consumer_key: str This is the identifier for this application which you
  541.           should have received when you registered your application with Google
  542.           to use OAuth.
  543.       consumer_secret: str (optional) The shared secret between your app and
  544.           Google which provides evidence that this request is coming from you
  545.           application and not another app. If present, this libraries assumes
  546.           you want to use an HMAC signature to verify requests. Keep this data
  547.           a secret.
  548.       rsa_private_key: str (optional) The RSA private key which is used to
  549.           generate a digital signature which is checked by Google's server. If
  550.           present, this library assumes that you want to use an RSA signature
  551.           to verify requests. Keep this data a secret.
  552.       url: The URL to which a request for a token should be made. The default
  553.           is Google's OAuth request token provider.
  554.     """
  555.     http_request = None
  556.     if rsa_private_key is not None:
  557.       http_request = gdata.gauth.generate_request_for_request_token(
  558.           consumer_key, gdata.gauth.RSA_SHA1, scopes,
  559.           rsa_key=rsa_private_key, auth_server_url=url, next=next)
  560.     elif consumer_secret is not None:
  561.       http_request = gdata.gauth.generate_request_for_request_token(
  562.           consumer_key, gdata.gauth.HMAC_SHA1, scopes,
  563.           consumer_secret=consumer_secret, auth_server_url=url, next=next)
  564.     else:
  565.       raise MissingOAuthParameters(
  566.           'To request an OAuth token, you must provide your consumer secret'
  567.           ' or your private RSA key.')
  568.  
  569.     response = self.http_client.request(http_request)
  570.     response_body = response.read()
  571.  
  572.     if response.status != 200:
  573.       raise error_from_response('Unable to obtain OAuth request token',
  574.                                 response, RequestError, response_body)
  575.  
  576.     if rsa_private_key is not None:
  577.       return gdata.gauth.rsa_token_from_body(response_body, consumer_key,
  578.                                              rsa_private_key,
  579.                                              gdata.gauth.REQUEST_TOKEN)
  580.     elif consumer_secret is not None:
  581.       return gdata.gauth.hmac_token_from_body(response_body, consumer_key,
  582.                                               consumer_secret,
  583.                                               gdata.gauth.REQUEST_TOKEN)
  584.  
  585.   GetOAuthToken = get_oauth_token
  586.  
  587.   def get_access_token(self, request_token,
  588.                        url=gdata.gauth.ACCESS_TOKEN_URL):
  589.     """Exchanges an authorized OAuth request token for an access token.
  590.  
  591.     Contacts the Google OAuth server to upgrade a previously authorized
  592.     request token. Once the request token is upgraded to an access token,
  593.     the access token may be used to access the user's data.
  594.  
  595.     For more details, see the Google Accounts OAuth documentation:
  596.     http://code.google.com/apis/accounts/docs/OAuth.html#AccessToken
  597.  
  598.     Args:
  599.       request_token: An OAuth token which has been authorized by the user.
  600.       url: (optional) The URL to which the upgrade request should be sent.
  601.           Defaults to: https://www.google.com/accounts/OAuthAuthorizeToken
  602.     """
  603.     http_request = gdata.gauth.generate_request_for_access_token(
  604.         request_token, auth_server_url=url)
  605.     response = self.http_client.request(http_request)
  606.     response_body = response.read()
  607.     if response.status != 200:
  608.       raise error_from_response(
  609.           'Unable to upgrade OAuth request token to access token',
  610.           response, RequestError, response_body)
  611.  
  612.     return gdata.gauth.upgrade_to_access_token(request_token, response_body)
  613.  
  614.   GetAccessToken = get_access_token
  615.  
  616.   def modify_request(self, http_request):
  617.     """Adds or changes request before making the HTTP request.
  618.  
  619.     This client will add the API version if it is specified.
  620.     Subclasses may override this method to add their own request
  621.     modifications before the request is made.
  622.     """
  623.     http_request = atom.client.AtomPubClient.modify_request(self,
  624.                                                             http_request)
  625.     if self.api_version is not None:
  626.       http_request.headers['GData-Version'] = self.api_version
  627.     return http_request
  628.  
  629.   ModifyRequest = modify_request
  630.  
  631.   def get_feed(self, uri, auth_token=None, converter=None,
  632.                desired_class=gdata.data.GDFeed, **kwargs):
  633.     return self.request(method='GET', uri=uri, auth_token=auth_token,
  634.                         converter=converter, desired_class=desired_class,
  635.                         **kwargs)
  636.  
  637.   GetFeed = get_feed
  638.  
  639.   def get_entry(self, uri, auth_token=None, converter=None,
  640.                 desired_class=gdata.data.GDEntry, etag=None, **kwargs):
  641.     http_request = atom.http_core.HttpRequest()
  642.     # Conditional retrieval
  643.     if etag is not None:
  644.       http_request.headers['If-None-Match'] = etag
  645.     return self.request(method='GET', uri=uri, auth_token=auth_token,
  646.                         http_request=http_request, converter=converter,
  647.                         desired_class=desired_class, **kwargs)
  648.  
  649.   GetEntry = get_entry
  650.  
  651.   def get_next(self, feed, auth_token=None, converter=None,
  652.                desired_class=None, **kwargs):
  653.     """Fetches the next set of results from the feed.
  654.  
  655.     When requesting a feed, the number of entries returned is capped at a
  656.     service specific default limit (often 25 entries). You can specify your
  657.     own entry-count cap using the max-results URL query parameter. If there
  658.     are more results than could fit under max-results, the feed will contain
  659.     a next link. This method performs a GET against this next results URL.
  660.  
  661.     Returns:
  662.       A new feed object containing the next set of entries in this feed.
  663.     """
  664.     if converter is None and desired_class is None:
  665.       desired_class = feed.__class__
  666.     return self.get_feed(feed.find_next_link(), auth_token=auth_token,
  667.                          converter=converter, desired_class=desired_class,
  668.                          **kwargs)
  669.  
  670.   GetNext = get_next
  671.  
  672.   # TODO: add a refresh method to re-fetch the entry/feed from the server
  673.   # if it has been updated.
  674.  
  675.   def post(self, entry, uri, auth_token=None, converter=None,
  676.            desired_class=None, **kwargs):
  677.     if converter is None and desired_class is None:
  678.       desired_class = entry.__class__
  679.     http_request = atom.http_core.HttpRequest()
  680.     http_request.add_body_part(
  681.         entry.to_string(get_xml_version(self.api_version)),
  682.         'application/atom+xml')
  683.     return self.request(method='POST', uri=uri, auth_token=auth_token,
  684.                         http_request=http_request, converter=converter,
  685.                         desired_class=desired_class, **kwargs)
  686.  
  687.   Post = post
  688.  
  689.   def update(self, entry, auth_token=None, force=False, **kwargs):
  690.     """Edits the entry on the server by sending the XML for this entry.
  691.  
  692.     Performs a PUT and converts the response to a new entry object with a
  693.     matching class to the entry passed in.
  694.  
  695.     Args:
  696.       entry:
  697.       auth_token:
  698.       force: boolean stating whether an update should be forced. Defaults to
  699.              False. Normally, if a change has been made since the passed in
  700.              entry was obtained, the server will not overwrite the entry since
  701.              the changes were based on an obsolete version of the entry.
  702.              Setting force to True will cause the update to silently
  703.              overwrite whatever version is present.
  704.  
  705.     Returns:
  706.       A new Entry object of a matching type to the entry which was passed in.
  707.     """
  708.     http_request = atom.http_core.HttpRequest()
  709.     http_request.add_body_part(
  710.         entry.to_string(get_xml_version(self.api_version)),
  711.         'application/atom+xml')
  712.     # Include the ETag in the request if present.
  713.     if force:
  714.       http_request.headers['If-Match'] = '*'
  715.     elif hasattr(entry, 'etag') and entry.etag:
  716.       http_request.headers['If-Match'] = entry.etag
  717.  
  718.     return self.request(method='PUT', uri=entry.find_edit_link(),
  719.                         auth_token=auth_token, http_request=http_request,
  720.                         desired_class=entry.__class__, **kwargs)
  721.  
  722.   Update = update
  723.  
  724.   def delete(self, entry_or_uri, auth_token=None, force=False, **kwargs):
  725.     http_request = atom.http_core.HttpRequest()
  726.       
  727.     # Include the ETag in the request if present.
  728.     if force:
  729.       http_request.headers['If-Match'] = '*'
  730.     elif hasattr(entry_or_uri, 'etag') and entry_or_uri.etag:
  731.       http_request.headers['If-Match'] = entry_or_uri.etag
  732.  
  733.     # If the user passes in a URL, just delete directly, may not work as
  734.     # the service might require an ETag.
  735.     if isinstance(entry_or_uri, (str, unicode, atom.http_core.Uri)):
  736.       return self.request(method='DELETE', uri=entry_or_uri,
  737.                           http_request=http_request, auth_token=auth_token,
  738.                           **kwargs)
  739.  
  740.     return self.request(method='DELETE', uri=entry_or_uri.find_edit_link(),
  741.                         http_request=http_request, auth_token=auth_token,
  742.                         **kwargs)
  743.  
  744.   Delete = delete
  745.  
  746.   #TODO: implement batch requests.
  747.   #def batch(feed, uri, auth_token=None, converter=None, **kwargs):
  748.   #  pass
  749.  
  750.   # TODO: add a refresh method to request a conditional update to an entry
  751.   # or feed.
  752.  
  753.  
  754. def _add_query_param(param_string, value, http_request):
  755.   if value:
  756.     http_request.uri.query[param_string] = value
  757.  
  758.  
  759. class Query(object):
  760.  
  761.   def __init__(self, text_query=None, categories=None, author=None, alt=None,
  762.                updated_min=None, updated_max=None, pretty_print=False,
  763.                published_min=None, published_max=None, start_index=None,
  764.                max_results=None, strict=False):
  765.     """Constructs a Google Data Query to filter feed contents serverside.
  766.  
  767.     Args:
  768.       text_query: Full text search str (optional)
  769.       categories: list of strings (optional). Each string is a required
  770.           category. To include an 'or' query, put a | in the string between
  771.           terms. For example, to find everything in the Fitz category and
  772.           the Laurie or Jane category (Fitz and (Laurie or Jane)) you would
  773.           set categories to ['Fitz', 'Laurie|Jane'].
  774.       author: str (optional) The service returns entries where the author
  775.           name and/or email address match your query string.
  776.       alt: str (optional) for the Alternative representation type you'd like
  777.           the feed in. If you don't specify an alt parameter, the service
  778.           returns an Atom feed. This is equivalent to alt='atom'.
  779.           alt='rss' returns an RSS 2.0 result feed.
  780.           alt='json' returns a JSON representation of the feed.
  781.           alt='json-in-script' Requests a response that wraps JSON in a script
  782.           tag.
  783.           alt='atom-in-script' Requests an Atom response that wraps an XML
  784.           string in a script tag.
  785.           alt='rss-in-script' Requests an RSS response that wraps an XML
  786.           string in a script tag.
  787.       updated_min: str (optional), RFC 3339 timestamp format, lower bounds.
  788.           For example: 2005-08-09T10:57:00-08:00
  789.       updated_max: str (optional) updated time must be earlier than timestamp.
  790.       pretty_print: boolean (optional) If True the server's XML response will
  791.           be indented to make it more human readable. Defaults to False.
  792.       published_min: str (optional), Similar to updated_min but for published
  793.           time.
  794.       published_max: str (optional), Similar to updated_max but for published
  795.           time.
  796.       start_index: int or str (optional) 1-based index of the first result to
  797.           be retrieved. Note that this isn't a general cursoring mechanism.
  798.           If you first send a query with ?start-index=1&max-results=10 and
  799.           then send another query with ?start-index=11&max-results=10, the
  800.           service cannot guarantee that the results are equivalent to
  801.           ?start-index=1&max-results=20, because insertions and deletions
  802.           could have taken place in between the two queries.
  803.       max_results: int or str (optional) Maximum number of results to be
  804.           retrieved. Each service has a default max (usually 25) which can
  805.           vary from service to service. There is also a service-specific
  806.           limit to the max_results you can fetch in a request.
  807.       strict: boolean (optional) If True, the server will return an error if
  808.           the server does not recognize any of the parameters in the request
  809.           URL. Defaults to False.
  810.     """
  811.     self.text_query = text_query
  812.     self.categories = categories or []
  813.     self.author = author
  814.     self.alt = alt
  815.     self.updated_min = updated_min
  816.     self.updated_max = updated_max
  817.     self.pretty_print = pretty_print
  818.     self.published_min = published_min
  819.     self.published_max = published_max
  820.     self.start_index = start_index
  821.     self.max_results = max_results
  822.     self.strict = strict
  823.  
  824.   def modify_request(self, http_request):
  825.     _add_query_param('q', self.text_query, http_request)
  826.     if self.categories:
  827.       http_request.uri.query['categories'] = ','.join(self.categories)
  828.     _add_query_param('author', self.author, http_request)
  829.     _add_query_param('alt', self.alt, http_request)
  830.     _add_query_param('updated-min', self.updated_min, http_request)
  831.     _add_query_param('updated-max', self.updated_max, http_request)
  832.     if self.pretty_print:
  833.       http_request.uri.query['prettyprint'] = 'true'
  834.     _add_query_param('published-min', self.published_min, http_request)
  835.     _add_query_param('published-max', self.published_max, http_request)
  836.     if self.start_index is not None:
  837.       http_request.uri.query['start-index'] = str(self.start_index)
  838.     if self.max_results is not None:
  839.       http_request.uri.query['max-results'] = str(self.max_results)
  840.     if self.strict:
  841.       http_request.uri.query['strict'] = 'true'
  842.  
  843.  
  844.   ModifyRequest = modify_request
  845.  
  846.  
  847. class GDQuery(atom.http_core.Uri):
  848.  
  849.   def _get_text_query(self):
  850.     return self.query['q']
  851.  
  852.   def _set_text_query(self, value):
  853.     self.query['q'] = value
  854.  
  855.   text_query = property(_get_text_query, _set_text_query,
  856.       doc='The q parameter for searching for an exact text match on content')
  857.  
  858.  
  859. class ResumableUploader(object):
  860.   """Resumable upload helper for the Google Data protocol."""
  861.  
  862.   DEFAULT_CHUNK_SIZE = 5242880  # 5MB
  863.  
  864.   def __init__(self, client, file_handle, content_type, total_file_size,
  865.                chunk_size=None, desired_class=None):
  866.     """Starts a resumable upload to a service that supports the protocol.
  867.  
  868.     Args:
  869.       client: gdata.client.GDClient A Google Data API service.
  870.       file_handle: object A file-like object containing the file to upload.
  871.       content_type: str The mimetype of the file to upload.
  872.       total_file_size: int The file's total size in bytes.
  873.       chunk_size: int The size of each upload chunk. If None, the
  874.           DEFAULT_CHUNK_SIZE will be used.
  875.       desired_class: object (optional) The type of gdata.data.GDEntry to parse
  876.           the completed entry as. This should be specific to the API.
  877.     """
  878.     self.client = client
  879.     self.file_handle = file_handle
  880.     self.content_type = content_type
  881.     self.total_file_size = total_file_size
  882.     self.chunk_size = chunk_size or self.DEFAULT_CHUNK_SIZE
  883.     self.desired_class = desired_class or gdata.data.GDEntry
  884.     self.upload_uri = None
  885.  
  886.     # Send the entire file if the chunk size is less than fize's total size.
  887.     if self.total_file_size <= self.chunk_size:
  888.       self.chunk_size = total_file_size
  889.  
  890.   def _init_session(self, resumable_media_link, entry=None, headers=None,
  891.                     auth_token=None):
  892.     """Starts a new resumable upload to a service that supports the protocol.
  893.  
  894.     The method makes a request to initiate a new upload session. The unique
  895.     upload uri returned by the server (and set in this method) should be used
  896.     to send upload chunks to the server.
  897.  
  898.     Args:
  899.       resumable_media_link: str The full URL for the #resumable-create-media or
  900.           #resumable-edit-media link for starting a resumable upload request or
  901.           updating media using a resumable PUT.
  902.       entry: A (optional) gdata.data.GDEntry containging metadata to create the 
  903.           upload from.
  904.       headers: dict (optional) Additional headers to send in the initial request
  905.           to create the resumable upload request. These headers will override
  906.           any default headers sent in the request. For example:
  907.           headers={'Slug': 'MyTitle'}.
  908.       auth_token: (optional) An object which sets the Authorization HTTP header
  909.           in its modify_request method. Recommended classes include
  910.           gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken
  911.           among others.
  912.  
  913.     Returns:
  914.       The final Atom entry as created on the server. The entry will be
  915.       parsed accoring to the class specified in self.desired_class.
  916.  
  917.     Raises:
  918.       RequestError if the unique upload uri is not set or the
  919.       server returns something other than an HTTP 308 when the upload is
  920.       incomplete.
  921.     """
  922.     http_request = atom.http_core.HttpRequest()
  923.     
  924.     # Send empty POST if Atom XML wasn't specified.
  925.     if entry is None:
  926.       http_request.add_body_part('', self.content_type, size=0)
  927.     else:
  928.       http_request.add_body_part(str(entry), 'application/atom+xml',
  929.                                  size=len(str(entry)))
  930.     http_request.headers['X-Upload-Content-Type'] = self.content_type
  931.     http_request.headers['X-Upload-Content-Length'] = self.total_file_size
  932.  
  933.     if headers is not None:
  934.       http_request.headers.update(headers)
  935.  
  936.     response = self.client.request(method='POST',
  937.                                    uri=resumable_media_link,
  938.                                    auth_token=auth_token,
  939.                                    http_request=http_request)
  940.  
  941.     self.upload_uri = (response.getheader('location') or
  942.                        response.getheader('Location'))
  943.  
  944.   _InitSession = _init_session
  945.  
  946.   def upload_chunk(self, start_byte, content_bytes):
  947.     """Uploads a byte range (chunk) to the resumable upload server.
  948.  
  949.     Args:
  950.       start_byte: int The byte offset of the total file where the byte range
  951.           passed in lives.
  952.       content_bytes: str The file contents of this chunk.
  953.  
  954.     Returns:
  955.       The final Atom entry created on the server. The entry object's type will
  956.       be the class specified in self.desired_class.
  957.  
  958.     Raises:
  959.       RequestError if the unique upload uri is not set or the
  960.       server returns something other than an HTTP 308 when the upload is
  961.       incomplete.
  962.     """
  963.     if self.upload_uri is None:
  964.       raise RequestError('Resumable upload request not initialized.')
  965.  
  966.     # Adjustment if last byte range is less than defined chunk size.
  967.     chunk_size = self.chunk_size
  968.     if len(content_bytes) <= chunk_size:
  969.       chunk_size = len(content_bytes)
  970.  
  971.     http_request = atom.http_core.HttpRequest()
  972.     http_request.add_body_part(content_bytes, self.content_type,
  973.                                size=len(content_bytes))
  974.     http_request.headers['Content-Range'] = ('bytes %s-%s/%s'
  975.                                              % (start_byte,
  976.                                                 start_byte + chunk_size - 1,
  977.                                                 self.total_file_size))
  978.  
  979.     try:
  980.       response = self.client.request(method='POST', uri=self.upload_uri,
  981.                                      http_request=http_request,
  982.                                      desired_class=self.desired_class)
  983.       return response
  984.     except RequestError, error:
  985.       if error.status == 308:
  986.         return None
  987.       else:
  988.         raise error
  989.  
  990.   UploadChunk = upload_chunk
  991.  
  992.   def upload_file(self, resumable_media_link, entry=None, headers=None,
  993.                   auth_token=None):
  994.     """Uploads an entire file in chunks using the resumable upload protocol.
  995.  
  996.     If you are interested in pausing an upload or controlling the chunking
  997.     yourself, use the upload_chunk() method instead.
  998.  
  999.     Args:
  1000.       resumable_media_link: str The full URL for the #resumable-create-media for
  1001.           starting a resumable upload request.
  1002.       entry: A (optional) gdata.data.GDEntry containging metadata to create the 
  1003.           upload from.
  1004.       headers: dict Additional headers to send in the initial request to create
  1005.           the resumable upload request. These headers will override any default
  1006.           headers sent in the request. For example: headers={'Slug': 'MyTitle'}.
  1007.       auth_token: (optional) An object which sets the Authorization HTTP header
  1008.           in its modify_request method. Recommended classes include
  1009.           gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken
  1010.           among others.
  1011.  
  1012.     Returns:
  1013.       The final Atom entry created on the server. The entry object's type will
  1014.       be the class specified in self.desired_class.
  1015.  
  1016.     Raises:
  1017.       RequestError if anything other than a HTTP 308 is returned
  1018.       when the request raises an exception.
  1019.     """
  1020.     self._init_session(resumable_media_link, headers=headers,
  1021.                        auth_token=auth_token, entry=entry)
  1022.  
  1023.     start_byte = 0
  1024.     entry = None
  1025.  
  1026.     while not entry:
  1027.       entry = self.upload_chunk(
  1028.           start_byte, self.file_handle.read(self.chunk_size))
  1029.       start_byte += self.chunk_size
  1030.  
  1031.     return entry
  1032.  
  1033.   UploadFile = upload_file
  1034.  
  1035.   def update_file(self, entry_or_resumable_edit_link, headers=None, force=False,
  1036.                   auth_token=None):
  1037.     """Updates the contents of an existing file using the resumable protocol.
  1038.  
  1039.     If you are interested in pausing an upload or controlling the chunking
  1040.     yourself, use the upload_chunk() method instead.
  1041.  
  1042.     Args:
  1043.       entry_or_resumable_edit_link: object or string A gdata.data.GDEntry for
  1044.           the entry/file to update or the full uri of the link with rel
  1045.           #resumable-edit-media.
  1046.       headers: dict Additional headers to send in the initial request to create
  1047.           the resumable upload request. These headers will override any default
  1048.           headers sent in the request. For example: headers={'Slug': 'MyTitle'}.
  1049.       force boolean (optional) True to force an update and set the If-Match
  1050.           header to '*'. If False and entry_or_resumable_edit_link is a
  1051.           gdata.data.GDEntry object, its etag value is used. Otherwise this
  1052.           parameter should be set to True to force the update.
  1053.       auth_token: (optional) An object which sets the Authorization HTTP header
  1054.           in its modify_request method. Recommended classes include
  1055.           gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken
  1056.           among others.
  1057.  
  1058.     Returns:
  1059.       The final Atom entry created on the server. The entry object's type will
  1060.       be the class specified in self.desired_class.
  1061.  
  1062.     Raises:
  1063.       RequestError if anything other than a HTTP 308 is returned
  1064.       when the request raises an exception.
  1065.     """
  1066.     # Need to override the POST request for a resumable update (required).
  1067.     customer_headers = {'X-HTTP-Method-Override': 'PUT'}
  1068.  
  1069.     if headers is not None:
  1070.       customer_headers.update(headers)
  1071.  
  1072.     if isinstance(entry_or_resumable_edit_link, gdata.data.GDEntry):
  1073.       resumable_edit_link = entry_or_resumable_edit_link.find_url(
  1074.           'http://schemas.google.com/g/2005#resumable-edit-media')
  1075.       customer_headers['If-Match'] = entry_or_resumable_edit_link.etag
  1076.     else:
  1077.       resumable_edit_link = entry_or_resumable_edit_link
  1078.  
  1079.     if force:
  1080.       customer_headers['If-Match'] = '*'
  1081.  
  1082.     return self.upload_file(resumable_edit_link, headers=customer_headers,
  1083.                             auth_token=auth_token)
  1084.  
  1085.   UpdateFile = update_file
  1086.  
  1087.   def query_upload_status(self, uri=None):
  1088.     """Queries the current status of a resumable upload request.
  1089.  
  1090.     Args:
  1091.       uri: str (optional) A resumable upload uri to query and override the one
  1092.           that is set in this object.
  1093.  
  1094.     Returns:
  1095.       An integer representing the file position (byte) to resume the upload from
  1096.       or True if the upload is complete.
  1097.  
  1098.     Raises:
  1099.       RequestError if anything other than a HTTP 308 is returned
  1100.       when the request raises an exception.
  1101.     """
  1102.     # Override object's unique upload uri.
  1103.     if uri is None:
  1104.       uri = self.upload_uri
  1105.  
  1106.     http_request = atom.http_core.HttpRequest()
  1107.     http_request.headers['Content-Length'] = '0'
  1108.     http_request.headers['Content-Range'] = 'bytes */%s' % self.total_file_size
  1109.  
  1110.     try:
  1111.       response = self.client.request(
  1112.           method='POST', uri=uri, http_request=http_request)
  1113.       if response.status == 201:
  1114.         return True
  1115.       else:
  1116.         raise error_from_response(
  1117.             '%s returned by server' % response.status, response, RequestError)
  1118.     except RequestError, error:
  1119.       if error.status == 308:
  1120.         for pair in error.headers:
  1121.           if pair[0].capitalize() == 'Range':
  1122.             return int(pair[1].split('-')[1]) + 1
  1123.       else:
  1124.         raise error
  1125.  
  1126.   QueryUploadStatus = query_upload_status
  1127.